/**
 * Copyright (c) 2017-present, Facebook, Inc.
 * All rights reserved.
 *
 * This source code is licensed under the BSD-style license found in the
 * LICENSE file in the root directory of this source tree. An additional grant
 * of patent rights can be found in the PATENTS file in the same directory.
 *
 * @flow strict-local
 * @format
 * @emails oncall+nuclide
 */
import invariant from 'assert';

import ContextMenu from '../ContextMenu';

describe('ContextMenu', () => {
  const cssSelector = '.nuclide-context-menu-unit-test';
  let div: HTMLDivElement;
  let menu;

  beforeEach(() => {
    div = document.createElement('div');
    div.className = cssSelector.substring(1);
    atom.views.getView(atom.workspace).appendChild(div);
  });

  afterEach(() => {
    if (menu != null) {
      menu.dispose();
    }
    if (div != null) {
      invariant(div.parentNode != null);
      div.parentNode.removeChild(div);
    }
  });

  it('initializes an empty ContextMenu properly', () => {
    const options = {
      type: 'root',
      cssSelector,
    };
    menu = new ContextMenu(options);
    expect(menu.isEmpty()).toBe(true);
  });

  it('items added to a context menu appear in priority order', async () => {
    const options = {
      type: 'root',
      cssSelector,
    };
    menu = new ContextMenu(options);

    menu.addItem({label: 'second'}, 20);
    menu.addItem({label: 'first', command: 'nuclide-do-something'}, 10);
    menu.addItem({label: 'fourth'}, 40);
    menu.addItem({label: 'third'}, 30);

    await waitForNextTick();

    expect(getTemplateForContextMenu()).toEqual([
      {label: 'first', command: 'nuclide-do-something'},
      {label: 'second'},
      {label: 'third'},
      {label: 'fourth'},
    ]);
  });

  it('can handle a mix of menu and submenu items', async () => {
    const options = {
      type: 'root',
      cssSelector,
    };
    menu = new ContextMenu(options);

    menu.addItem({label: 'two'}, 20);
    menu.addItem({label: 'one'}, 10);
    menu.addItem({label: 'four'}, 40);
    menu.addItem({label: 'three'}, 30);

    const submenu = new ContextMenu({
      type: 'submenu',
      label: 'sub',
      parent: menu,
    });
    menu.addSubmenu(submenu, 25);
    submenu.addItem({label: 'B'}, 2);
    submenu.addItem({label: 'A'}, 1);
    submenu.addItem({label: 'C'}, 3);

    await waitForNextTick();

    expect(getTemplateForContextMenu()).toEqual([
      {label: 'one'},
      {label: 'two'},
      {
        label: 'sub',
        submenu: [{label: 'A'}, {label: 'B'}, {label: 'C'}],
      },
      {label: 'three'},
      {label: 'four'},
    ]);
  });

  it('dispose() returned for an item can be used to remove a menu item', async () => {
    const options = {
      type: 'root',
      cssSelector,
    };
    menu = new ContextMenu(options);

    const disposableForItem = menu.addItem({label: 'two'}, 20);
    menu.addItem({label: 'one'}, 10);
    menu.addItem({label: 'four'}, 40);
    menu.addItem({label: 'three'}, 30);

    const submenu = new ContextMenu({
      type: 'submenu',
      label: 'sub',
      parent: menu,
    });
    const disposableForSubmenu = menu.addSubmenu(submenu, 25);
    submenu.addItem({label: 'B'}, 2);
    const disposableForSubmenuItem = submenu.addItem({label: 'A'}, 1);
    submenu.addItem({label: 'C'}, 3);

    await waitForNextTick();
    expect(getTemplateForContextMenu()).toEqual([
      {label: 'one'},
      {label: 'two'},
      {
        label: 'sub',
        submenu: [{label: 'A'}, {label: 'B'}, {label: 'C'}],
      },
      {label: 'three'},
      {label: 'four'},
    ]);

    // Note that unlike addItem() or addSubmenu(), invoking dispose() is synchronous.

    disposableForItem.dispose();
    expect(getTemplateForContextMenu()).toEqual([
      {label: 'one'},
      {
        label: 'sub',
        submenu: [{label: 'A'}, {label: 'B'}, {label: 'C'}],
      },
      {label: 'three'},
      {label: 'four'},
    ]);

    disposableForSubmenuItem.dispose();
    expect(getTemplateForContextMenu()).toEqual([
      {label: 'one'},
      {
        label: 'sub',
        submenu: [{label: 'B'}, {label: 'C'}],
      },
      {label: 'three'},
      {label: 'four'},
    ]);

    disposableForSubmenu.dispose();
    expect(getTemplateForContextMenu()).toEqual([
      {label: 'one'},
      {label: 'three'},
      {label: 'four'},
    ]);
  });

  it('removing all submenu items should result in it being filtered from view', async () => {
    const options = {
      type: 'root',
      cssSelector,
    };
    menu = new ContextMenu(options);

    menu.addItem({label: 'two'}, 20);
    menu.addItem({label: 'one'}, 10);
    menu.addItem({label: 'four'}, 40);
    menu.addItem({label: 'three'}, 30);

    const submenu = new ContextMenu({
      type: 'submenu',
      label: 'sub',
      parent: menu,
    });
    menu.addSubmenu(submenu, 25);
    const disposableForSubmenuItem1 = submenu.addItem({label: 'A'}, 1);
    const disposableForSubmenuItem2 = submenu.addItem({label: 'B'}, 2);
    const disposableForSubmenuItem3 = submenu.addItem({label: 'C'}, 3);

    await waitForNextTick();

    expect(getTemplateForContextMenu()).toEqual([
      {label: 'one'},
      {label: 'two'},
      {
        label: 'sub',
        submenu: [{label: 'A'}, {label: 'B'}, {label: 'C'}],
      },
      {label: 'three'},
      {label: 'four'},
    ]);

    disposableForSubmenuItem1.dispose();
    disposableForSubmenuItem2.dispose();
    disposableForSubmenuItem3.dispose();
    expect(getTemplateForContextMenu()).toEqual([
      {label: 'one'},
      {label: 'two'},
      {label: 'three'},
      {label: 'four'},
    ]);

    // It should still be possible to add items to the submenu after it has been cleared out.
    submenu.addItem({label: 'D'}, 4);

    await waitForNextTick();

    expect(getTemplateForContextMenu()).toEqual([
      {label: 'one'},
      {label: 'two'},
      {
        label: 'sub',
        submenu: [{label: 'D'}],
      },
      {label: 'three'},
      {label: 'four'},
    ]);
  });

  it('.isEmpty()', () => {
    const options = {
      type: 'root',
      cssSelector,
    };
    menu = new ContextMenu(options);
    expect(menu.isEmpty()).toBe(true);

    const submenu = new ContextMenu({
      type: 'submenu',
      label: 'sub',
      parent: menu,
    });
    const disposableForSubmenu = menu.addSubmenu(submenu, 20);
    expect(menu.isEmpty()).toBe(false);

    disposableForSubmenu.dispose();
    expect(menu.isEmpty()).toBe(true);
  });

  it('.dispose() removes all items', async () => {
    const options = {
      type: 'root',
      cssSelector,
    };
    menu = new ContextMenu(options);

    menu.addItem({label: 'two'}, 20);
    menu.addItem({label: 'one'}, 10);
    menu.addItem({label: 'four'}, 40);
    menu.addItem({label: 'three'}, 30);

    const submenu = new ContextMenu({
      type: 'submenu',
      label: 'sub',
      parent: menu,
    });
    menu.addSubmenu(submenu, 25);
    submenu.addItem({label: 'B'}, 2);
    submenu.addItem({label: 'A'}, 1);
    submenu.addItem({label: 'C'}, 3);

    await waitForNextTick();

    expect(getTemplateForContextMenu()).toEqual([
      {label: 'one'},
      {label: 'two'},
      {
        label: 'sub',
        submenu: [{label: 'A'}, {label: 'B'}, {label: 'C'}],
      },
      {label: 'three'},
      {label: 'four'},
    ]);

    expect(menu.isEmpty()).toBe(false);
    menu.dispose();
    expect(menu.isEmpty()).toBe(true);
    expect(getTemplateForContextMenu()).toEqual([]);
  });

  function getTemplateForContextMenu(): Array<atom$ContextMenuItem> {
    const template: Array<atom$ContextMenuItem> =
      // $FlowIgnore: This relies on an non-public API of Atom's ContextMenuManager.
      atom.contextMenu.templateForElement(div);
    const lastItem = template[template.length - 1];
    // Unfortunately, Atom does not give us a way to exclude the 'Inspect Element' item from
    // a custom context menu. For now, we exclude it from the template to reduce noise in our
    // unit tests.
    if (lastItem.label === 'Inspect Element') {
      template.pop();
    }
    return template;
  }
});

/**
 * Calls to ContextMenu.addItem() and ContextMenu.addSubmenu() on the same turn of the event loop
 * batch up their internal _sort() calls to run at the end of the current event loop. This function
 * facilitates waiting for that to happen.
 *
 * @return Promise that resolves on process.nextTick().
 */
function waitForNextTick(): Promise<void> {
  return new Promise(resolve => process.nextTick(resolve));
}
